<?php

declare(strict_types=1);

namespace GrumPHP\Task\Git;

use GrumPHP\Exception\RuntimeException;
use GrumPHP\Git\GitRepository;
use GrumPHP\Runner\TaskResult;
use GrumPHP\Runner\TaskResultInterface;
use GrumPHP\Task\Config\ConfigOptionsResolver;
use GrumPHP\Task\Config\EmptyTaskConfig;
use GrumPHP\Task\Config\TaskConfigInterface;
use GrumPHP\Task\Context\ContextInterface;
use GrumPHP\Task\Context\GitCommitMsgContext;
use GrumPHP\Task\TaskInterface;
use GrumPHP\Util\Regex;
use GrumPHP\Util\Str;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CommitMessage implements TaskInterface
{
    public const MERGE_COMMIT_REGEX =
        '(Merge branch|tag \'.+\'(?:\s.+)?|Merge remote-tracking branch \'.+\'|Merge pull request #\d+\s.+)';

    /**
     * @var TaskConfigInterface
     */
    private $config;

    /**
     * @var GitRepository
     */
    private $repository;

    public static function getConfigurableOptions(): ConfigOptionsResolver
    {
        $resolver = new OptionsResolver();
        $resolver->setDefaults([
            'allow_empty_message' => false,
            'enforce_capitalized_subject' => true,
            'enforce_no_subject_punctuations' => false,
            'enforce_no_subject_trailing_period' => true,
            'enforce_single_lined_subject' => true,
            'max_body_width' => 72,
            'max_subject_width' => 60,
            'case_insensitive' => true,
            'multiline' => true,
            'type_scope_conventions' => [],
            'skip_on_merge_commit' => true,
            'matchers' => [],
            'additional_modifiers' => '',
        ]);

        $resolver->addAllowedTypes('allow_empty_message', ['bool']);
        $resolver->addAllowedTypes('type_scope_conventions', ['array']);
        $resolver->addAllowedTypes('enforce_capitalized_subject', ['bool']);
        $resolver->addAllowedTypes('enforce_no_subject_punctuations', ['bool']);
        $resolver->addAllowedTypes('enforce_no_subject_trailing_period', ['bool']);
        $resolver->addAllowedTypes('enforce_single_lined_subject', ['bool']);
        $resolver->addAllowedTypes('max_body_width', ['int']);
        $resolver->addAllowedTypes('max_subject_width', ['int']);
        $resolver->addAllowedTypes('case_insensitive', ['bool']);
        $resolver->addAllowedTypes('skip_on_merge_commit', ['bool']);
        $resolver->addAllowedTypes('multiline', ['bool']);
        $resolver->addAllowedTypes('matchers', ['array']);
        $resolver->addAllowedTypes('additional_modifiers', ['string']);

        return ConfigOptionsResolver::fromOptionsResolver($resolver);
    }

    public function __construct(GitRepository $repository)
    {
        $this->repository = $repository;
        $this->config = new EmptyTaskConfig();
    }

    public function getConfig(): TaskConfigInterface
    {
        return $this->config;
    }

    public function withConfig(TaskConfigInterface $config): TaskInterface
    {
        $new = clone $this;
        $new->config = $config;

        return $new;
    }

    public function canRunInContext(ContextInterface $context): bool
    {
        return $context instanceof GitCommitMsgContext;
    }

    private static function withCommitMessage(string $errorMessage, string $commitMessage): string
    {
        return sprintf(
            "%s%sOriginal commit message:%s%s",
            $errorMessage,
            PHP_EOL,
            PHP_EOL,
            $commitMessage
        );
    }


    public function run(ContextInterface $context): TaskResultInterface
    {
        assert($context instanceof GitCommitMsgContext);

        $config = $this->getConfig()->getOptions();
        $commitMessage = $context->getCommitMessage();
        $exceptions = [];
        $isMergeCommit = $this->isMergeCommit($commitMessage);

        if ($isMergeCommit && $config['skip_on_merge_commit']) {
            return TaskResult::createSkipped($this, $context);
        }

        if (!(bool) $config['allow_empty_message'] && '' === trim($commitMessage)) {
            return TaskResult::createFailed(
                $this,
                $context,
                'Commit message should not be empty.',
            );
        }

        if ((bool) $config['enforce_capitalized_subject'] && !$this->subjectIsCapitalized($context)) {
            return TaskResult::createFailed(
                $this,
                $context,
                self::withCommitMessage('Subject should start with a capital letter.', $commitMessage)
            );
        }

        if ((bool) $config['enforce_single_lined_subject'] && !$this->subjectIsSingleLined($context)) {
            return TaskResult::createFailed(
                $this,
                $context,
                self::withCommitMessage('Subject should be one line and followed by a blank line.', $commitMessage)
            );
        }

        if ((bool) $config['enforce_no_subject_punctuations'] && $this->subjectHasPunctuations($context)) {
            return TaskResult::createFailed(
                $this,
                $context,
                self::withCommitMessage('Please omit all punctuations from commit message subject.', $commitMessage)
            );
        }

        if ((bool) $config['enforce_no_subject_trailing_period'] && $this->subjectHasTrailingPeriod($context)) {
            return TaskResult::createFailed(
                $this,
                $context,
                self::withCommitMessage('Please omit trailing period from commit message subject.', $commitMessage)
            );
        }

        if (!$isMergeCommit && $this->enforceTypeScopeConventions()) {
            try {
                $this->checkTypeScopeConventions($context);
            } catch (RuntimeException $e) {
                $exceptions[] = $e->getMessage();
            }
        }

        foreach ($config['matchers'] as $ruleName => $rule) {
            try {
                $this->runMatcher($config, $commitMessage, $rule, (string) $ruleName);
            } catch (RuntimeException $e) {
                $exceptions[] = $e->getMessage();
            }
        }

        if (\count($exceptions)) {
            return TaskResult::createFailed(
                $this,
                $context,
                self::withCommitMessage(implode(PHP_EOL, $exceptions), $commitMessage)
            );
        }

        return $this->enforceTextWidth($context);
    }

    private function enforceTextWidth(GitCommitMsgContext $context): TaskResult
    {
        $commitMessage = $context->getCommitMessage();
        $config = $this->getConfig()->getOptions();

        if ('' === trim($commitMessage)) {
            return TaskResult::createPassed($this, $context);
        }

        $errors = [];
        $lines = $this->getCommitMessageLinesWithoutComments($commitMessage);

        $subject = rtrim($lines[0]);
        if ($config['max_subject_width'] > 0) {
            $maxSubjectWidth = $config['max_subject_width'] + $this->getSpecialPrefixLength($subject);

            if (mb_strlen($subject) > $maxSubjectWidth) {
                $errors[] = sprintf('Please keep the subject <= %u characters.', $maxSubjectWidth);
            }
        }

        if ($config['max_body_width'] > 0) {
            foreach (\array_slice($lines, 2) as $index => $line) {
                if (mb_strlen(rtrim($line)) > $config['max_body_width']) {
                    $errors[] = sprintf(
                        'Line %u of commit message has > %u characters.',
                        (int)($index) + 3,
                        $config['max_body_width']
                    );
                }
            }
        }

        if (\count($errors)) {
            return TaskResult::createFailed(
                $this,
                $context,
                self::withCommitMessage(implode(PHP_EOL, $errors), $commitMessage)
            );
        }

        return TaskResult::createPassed($this, $context);
    }

    private function runMatcher(array $config, string $commitMessage, string $rule, string $ruleName): void
    {
        assert($rule !== '');
        $regex = new Regex($rule);

        if ((bool) $config['case_insensitive']) {
            $regex->addPatternModifier('i');
        }

        if ((bool) $config['multiline']) {
            $regex->addPatternModifier('m');
        }

        $additionalModifiersArray = array_filter(str_split((string) $config['additional_modifiers']));
        array_map([$regex, 'addPatternModifier'], $additionalModifiersArray);

        if (!preg_match((string) $regex, $commitMessage)) {
            throw new RuntimeException("Rule not matched: \"$ruleName\" $rule");
        }
    }

    private function getSpecialPrefixLength(string $string): int
    {
        if (1 !== preg_match('/^(fixup|squash)! /', $string, $match)) {
            return 0;
        }

        return mb_strlen($match[0]);
    }

    private function subjectHasPunctuations(GitCommitMsgContext $context): bool
    {
        $subjectLine = $this->getSubjectLine($context);

        if (trim($subjectLine) === '') {
            return false;
        }

        return Str::containsOneOf($subjectLine, ['.', '!', '?', ',']);
    }

    private function subjectHasTrailingPeriod(GitCommitMsgContext $context): bool
    {
        $subjectLine = $this->getSubjectLine($context);

        if ('' === trim($subjectLine)) {
            return false;
        }

        if ('.' !== mb_substr(rtrim($subjectLine), -1)) {
            return false;
        }

        return true;
    }

    private function subjectIsCapitalized(GitCommitMsgContext $context): bool
    {
        $commitMessage = $context->getCommitMessage();

        if ('' === trim($commitMessage)) {
            return true;
        }

        $lines = $this->getCommitMessageLinesWithoutComments($commitMessage);
        $subject = array_reduce($lines, function (?string $subject, string $line) {
            if (null !== $subject) {
                return $subject;
            }

            if ('' === trim($line)) {
                return null;
            }

            return $line;
        }, null);

        if (null === $subject || 1 !== preg_match('/^[[:punct:]]*(.)/u', $subject, $match)) {
            return false;
        }

        $firstLetter = $match[1] ?? '';

        return !(1 !== preg_match('/^(fixup|squash)!/u', $subject) && 1 !== preg_match('/[[:upper:]]/u', $firstLetter));
    }

    private function subjectIsSingleLined(GitCommitMsgContext $context): bool
    {
        $commitMessage = $context->getCommitMessage();

        if ('' === trim($commitMessage)) {
            return true;
        }

        $lines = $this->getCommitMessageLinesWithoutComments($commitMessage);

        return !(array_key_exists(1, $lines) && '' !== trim($lines[1]));
    }

    private function getCommitMessageLinesWithoutComments(string $commitMessage): array
    {
        $commentChar = trim($this->repository->tryToRunWithFallback(
            function (): ?string {
                return $this->repository->run('config', ['--get', 'core.commentChar']);
            },
            '#'
        ));

        $lines = preg_split('/\R/u', $commitMessage);
        $everythingBelowWillBeIgnored = false;

        return array_values(array_filter(
            $lines,
            function (string $line) use (&$everythingBelowWillBeIgnored, $commentChar) {
                if (mb_stripos($line, $commentChar.' Everything below it will be ignored.') !== false) {
                    $everythingBelowWillBeIgnored = true;
                    return false;
                }

                /** @psalm-suppress RedundantCondition - False positive */
                return 0 !== strpos($line, $commentChar) && !$everythingBelowWillBeIgnored;
            }
        ));
    }

    private function enforceTypeScopeConventions(): bool
    {
        $config = $this->getConfig()->getOptions();

        $conventionsKeys = array_keys($config['type_scope_conventions']);

        return in_array('types', $conventionsKeys) || in_array('scopes', $conventionsKeys);
    }

    /**
     * @throws RuntimeException
     */
    private function checkTypeScopeConventions(GitCommitMsgContext $context): void
    {
        $config = $this->getConfig()->getOptions();
        $subjectLine = $this->getSubjectLine($context);

        $types = $config['type_scope_conventions']['types'] ?? [];

        $scopes = $config['type_scope_conventions']['scopes'] ?? [];

        $specialPrefix = '(?:(?:fixup|squash)! )?';
        $typesPattern = '([a-zA-Z0-9]+)';
        $scopesPattern = '(:\s|(\(.+\)?:\s))';

        $subjectPattern = $config['type_scope_conventions']['subject_pattern'] ?? '([a-zA-Z0-9-_ #@\'\/\\"]+)';

        if (count($types) > 0) {
            $types = implode('|', $types);
            $typesPattern = '(' . $types . ')';
        }

        if (count($scopes) > 0) {
            $scopes = implode('|', $scopes);
            $scopesPattern = '(:\s|(\((?:' . $scopes . ')\)?:\s))';
        }

        $rule = '/^' . $specialPrefix . $typesPattern . $scopesPattern . $subjectPattern . '/';
        try {
            $this->runMatcher($config, $subjectLine, $rule, 'Invalid Type/Scope Format');
        } catch (RuntimeException $e) {
            throw $e;
        }
    }

    private function isMergeCommit(string $commitMessage): bool
    {
        return (bool) \preg_match(self::MERGE_COMMIT_REGEX, $commitMessage);
    }

    /**
     * Gets a clean subject line from the commit message
     *
     * @param $context
     * @return string
     */
    private function getSubjectLine(GitCommitMsgContext $context)
    {
        $commitMessage = $context->getCommitMessage();
        $lines = $this->getCommitMessageLinesWithoutComments($commitMessage);
        return (string) $lines[0];
    }
}
